package tp.badugi.net;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.net.Socket;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import tp.badugi.net.exceptions.IllegalCharactersException;
import tp.badugi.net.exceptions.IllegalParameterValueException;
import tp.badugi.net.exceptions.NameTakenException;
import tp.badugi.net.exceptions.NoSuchTableException;
import tp.badugi.net.exceptions.RequestTimedOutException;
import tp.badugi.net.exceptions.ServerCouldNotCompleteRequestException;
import tp.badugi.net.exceptions.TableFullException;
import static tp.badugi.net.ServerNetworkingChannel.*;

/**
 * This class provides a client side network communication API with the server.
 * <p>
 * An instance of this class should be created in order to connect to the server.
 * The <code>actionRequestListener</code>, <code>cardsRequestListener</code>,
 * and <code>statusUpdateListener</code> need to be set before the
 * {@link #connect() connect} method can be called.
 * <p>
 * If one or more of these parameters are not set when the {@link #connect() connect}
 * method is called, the method will thrown an exception.
 * <p>
 * Use the {@link #disconnect() disconnect} method to disconnect from the server.
 * 
 * @author bartek
 *
 */
public class ClientNetworkingChannel {
	
	private static final int SERVER_RESPONSE_TIMEOUT = 5000;
	private final static Logger LOGGER = Logger.getLogger(ClientNetworkingChannel.class.getName()); 
	
	public static final String PARAM_ACTION = "PARAM_ACTION";
	public static final String PARAM_AMOUNT = "PARAM_AMOUNT";
	public static final String PARAM_CHOSEN_CARDS = "PARAM_CHOSEN_CARDS";
	public static final String PARAM_MY_PLAYER_NAME = "PARAM_MY_PLAYER_NAME";
	public static final String PARAM_TABLE_NAME = "PARAM_TABLE_NAME";
	public static final String PARAM_MAX_PLAYERS = "PARAM_MAX_PLAYERS";
	public static final String PARAM_STARTING_CHIPS = "PARAM_STARTING_CHIPS";
	public static final String PARAM_SMALL_BLIND = "PARAM_SMALL_BLIND";
	public static final String PARAM_BIG_BLIND = "PARAM_BIG_BLIND";
	public static final String REQUEST_CREATE_TABLE = "REQUEST_CREATE_TABLE";
	public static final String REQUEST_JOIN_TABLE = "REQUEST_JOIN_TABLE";
	public static final String REQUEST_LIST_TABLES = "REQUEST_LIST_TABLES";
	public static final String REQUEST_LEAVE_TABLE = "REQUEST_LEAVE_TABLE";
	public static final String NOTIFY_CONNECTION_CLOSE = "NOTIFY_CONNECTION_CLOSE";
	public static final String RESPONSE_CHOSEN_ACTION = "RESPONSE_CHOSEN_ACTION";
	public static final String RESPONSE_CHOSEN_CARDS = "RESPONSE_CHOSEN_CARDS";
	public static final String PING_RESPONSE = "PING_RESPONSE";
	public static final String ERROR_MSG_DECODING_REQUEST = "Error decoding request.";
	public static final String ERROR_MSG_BAD_PACKET = 
			"Packet not properly formatted on server side.";
	public static final String ERROR_MSG_ENCODING_REQUEST = "Error encoding request";
	
	private String ipAddress;
	private int portNumber;
	private Socket socket;
	private PrintWriter outputBuffer;
	private Thread inputProcessingThread;
	private iRequestListener requestListener;
	private iStatusUpdateListener statusUpdateListener;
	private JSONObject responseParameters;
	
	private class IndependentBufferedReader extends BufferedReader implements Runnable {
		
		public IndependentBufferedReader(Reader inputBuffer) {
			super(inputBuffer);
		}

		@Override
		public void run() {
			try {
				while (!Thread.interrupted()) {
					try {
						String message = readLine();
						if (message == null) {
							throw new IOException("Unable to read from socket.");
						}
						if (DEBUGGING_ON) {
							String time = getTime();
							System.out.println(time + ": Client received message: " + message);
						}
						String[] messageBlock = message.split(DELIMITER);
						String request = messageBlock[0];
						JSONObject parameters;
						if (messageBlock.length > 1) {
							parameters = new JSONObject(messageBlock[1]);
						}
						else {
							parameters = null;
						}
						
						ClientNetworkingChannel netChannel = ClientNetworkingChannel.this;
						switch (request) {
						case REQUEST_ACTION:
							netChannel.handleActionRequest(parameters);
							break;
						case REQUEST_CARDS:
							netChannel.handleCardsRequest(parameters);
							break;
						case RESPONSE_CREATE_TABLE:
							netChannel.handleCreateTableResponse(parameters);
							break;
						case RESPONSE_JOIN_TABLE:
							netChannel.handleJoinTableResponse(parameters);
							break;
						case RESPONSE_LIST_TABLES:
							netChannel.handleListTablesResponse(parameters);
							break;
						case RESPONSE_LEAVE_TABLE:
							netChannel.handleLeaveTableResponse(parameters);
							break;
						case NOTIFY_CARDS_RECEIVED:
							netChannel.handleCardsReceivedUpdate(parameters);
							break;
						case NOTIFY_BETTING_ROUND_CHANGED:
							netChannel.handleBettingRoundChangedUpdate(parameters);
							break;
						case NOTIFY_PLAYER_DISCONNECTED:
							netChannel.handlePlayerDisconnectedUpdate(parameters);
							break;
						case NOTIFY_PLAYER_IN_ACTION_CHANGED:
							netChannel.handlePlayerInActionChangedUpdate(parameters);
							break;
						case NOTIFY_PLAYER_JOINED:
							netChannel.handlePlayerJoinedUpdate(parameters);
							break;
						case NOTIFY_PLAYER_LEFT:
							netChannel.handlePlayerLeftUpdate(parameters);
							break;
						case NOTIFY_PLAYER_PERFORMED_ACTION:
							netChannel.handlePlayerPerformedActionUpdate(parameters);
							break;
						case NOTIFY_PLAYER_RECONNECTED:
							netChannel.handlePlayerReconnectedUpdate(parameters);
							break;	
						case NOTIFY_PLAYER_REPLACED_CARDS:
							netChannel.handlePlayerReplacedCardsUpdate(parameters);
							break;
						case NOTIFY_PLAYER_ON_BUTTON_CHANGED:
							netChannel.handlePlayerWithButtonChangedUpdate(parameters);
							break;
						case NOTIFY_WINNER_REVEALED:
							netChannel.handleWinnerRevealedUpdate(parameters);
							break;
						case NOTIFY_GAME_ABOUT_TO_START:
							netChannel.handleGameAboutToStartUpdate(parameters);
							break;
						case NOTIFY_GAME_ABOUT_TO_RESTART:
							netChannel.handleGameAboutToRestartUpdate(parameters);
							break;
						case PING_PROBE:
							netChannel.handlePingProbe();
							break;
						default:
							throw new IllegalArgumentException(
									"Don't know how to handle message: " + request);
						}
					}
					catch (IllegalArgumentException | JSONException e) {
						LOGGER.log(Level.SEVERE, ERROR_MSG_BAD_PACKET, e);
					}
				}
			}
			catch (IOException e) {
				if (!socket.isClosed()) {
					statusUpdateListener.connectionLostWithServer();
				}
			}
		}
	}
	
	/**
	 * Creates a <code>ClientNetworkingChannel</code> and sets the server
	 * IP address and port number.
	 * 
	 * @param ipAddress server IP Address (in the format "255.255.255.255").
	 * @param portNumber server port number.
	 */
	public ClientNetworkingChannel(String ipAddress, int portNumber) {
		this.ipAddress = ipAddress;
		this.portNumber = portNumber;
	}
	
	private void handleGameAboutToStartUpdate(JSONObject parameters) {
		try {
			int timeUntilStart = parameters.getInt(PARAM_TIME_UNTIL_START);
			JSONObject jsonGameStatus = parameters.getJSONObject(PARAM_GAME_STATUS);
			final GameStatus gameStatus = GameStatus.fromJSONObject(jsonGameStatus);
			statusUpdateListener.gameAboutToStart(timeUntilStart, gameStatus);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}
	
	private void handleGameAboutToRestartUpdate(JSONObject parameters) {
		try {
			int timeUntilRestart = parameters.getInt(PARAM_TIME_UNTIL_RESTART);
			statusUpdateListener.gameAboutToRestart(timeUntilRestart);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePingProbe() {
		String requestMessage = PING_RESPONSE;
		sendMessage(requestMessage);
	}

	private void handleWinnerRevealedUpdate(JSONObject parameters) {
		try {
			JSONArray jsonRevealedCards = parameters.getJSONArray(PARAM_REVEALED_CARDS);
			String[][] revealedCards = new String[jsonRevealedCards.length()][4];
			for (int i = 0; i < jsonRevealedCards.length(); i++) {
				final JSONArray jsonCards = jsonRevealedCards.getJSONArray(i);
				for (int j = 0; j < 4; j++) {
					if (jsonCards.isNull(j)) {
						revealedCards[i][j] = "";
					}
					else {
						revealedCards[i][j] = jsonCards.getString(j);
					}
				}
			}
			JSONArray jsonWinnerIndexes = parameters.getJSONArray(PARAM_WINNER_INDEXES);
			int[] winnerIndexes = new int[jsonWinnerIndexes.length()];
			for (int i = 0; i < jsonWinnerIndexes.length(); i++) {
				winnerIndexes[i] = jsonWinnerIndexes.getInt(i);
			}
			JSONArray jsonPlayerNames = parameters.getJSONArray(PARAM_PLAYER_NAMES);
			String[] playerNames = new String[jsonPlayerNames.length()];
			for (int i = 0; i < jsonPlayerNames.length(); i++) {
				playerNames[i] = jsonPlayerNames.getString(i);
			}
			JSONArray jsonUpdateChipCounts = parameters.getJSONArray(PARAM_UPDATED_CHIP_COUNTS);
			long[] updatedChipCounts = new long[jsonUpdateChipCounts.length()];
			for (int i = 0; i < jsonUpdateChipCounts.length(); i++) {
				updatedChipCounts[i] = jsonUpdateChipCounts.getLong(i);
			}
			statusUpdateListener.winnerRevealed(
					winnerIndexes, 
					playerNames, 
					revealedCards, 
					updatedChipCounts);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerWithButtonChangedUpdate(JSONObject parameters) {
		try {
			int playerIndex = parameters.getInt(PARAM_PLAYER_INDEX);
			JSONArray jsonPlayerNames = parameters.getJSONArray(PARAM_PLAYER_NAMES);
			String[] playerNames = new String[jsonPlayerNames.length()];
			for (int i = 0; i < jsonPlayerNames.length(); i++) {
				playerNames[i] = jsonPlayerNames.getString(i);
			}
			statusUpdateListener.playerWithButtonChanged(playerIndex, playerNames);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerReplacedCardsUpdate(JSONObject parameters) {
		try {
			String playerName = parameters.getString(PARAM_PLAYER_NAME);
			final int cardCount = parameters.getInt(PARAM_CARD_COUNT);
			statusUpdateListener.playerReplacedCards(playerName, cardCount);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerReconnectedUpdate(JSONObject parameters) {
		try {
			String playerName = parameters.getString(PARAM_PLAYER_NAME);
			statusUpdateListener.playerReconnected(playerName);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerPerformedActionUpdate(JSONObject parameters) {
		try {
			int playerIndex = parameters.getInt(PARAM_PLAYER_INDEX);
			final String action = parameters.getString(PARAM_ACTION);
			final long amount = parameters.getLong(PARAM_AMOUNT);
			JSONObject jsonGameStatus = parameters.getJSONObject(PARAM_GAME_STATUS);
			final GameStatus gameStatus = GameStatus.fromJSONObject(jsonGameStatus);
			statusUpdateListener.playerPerformedAction(
					playerIndex, 
					action, 
					amount, 
					gameStatus);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerLeftUpdate(JSONObject parameters) {
		try {
			String playerName = parameters.getString(PARAM_PLAYER_NAME);
			JSONObject jsonGameStatus = parameters.getJSONObject(PARAM_GAME_STATUS);
			final GameStatus gameStatus = GameStatus.fromJSONObject(jsonGameStatus);
			statusUpdateListener.playerLeft(
					playerName, 
					gameStatus);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerJoinedUpdate(JSONObject parameters) {
		try {
			int playerIndex = parameters.getInt(PARAM_PLAYER_INDEX);
			JSONObject jsonGameStatus = parameters.getJSONObject(PARAM_GAME_STATUS);
			final GameStatus gameStatus = GameStatus.fromJSONObject(jsonGameStatus);
			statusUpdateListener.playerJoined(
					playerIndex, 
					gameStatus);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerInActionChangedUpdate(JSONObject parameters) {
		try {
			int playerIndex = parameters.getInt(PARAM_PLAYER_INDEX);
			JSONArray jsonPlayerNames = parameters.getJSONArray(PARAM_PLAYER_NAMES);
			String[] playerNames = new String[jsonPlayerNames.length()];
			for (int i = 0; i < jsonPlayerNames.length(); i++) {
				playerNames[i] = jsonPlayerNames.getString(i);
			}
			statusUpdateListener.playerInActionChanged(playerIndex, playerNames);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handlePlayerDisconnectedUpdate(JSONObject parameters) {
		try {
			String playerName = parameters.getString(PARAM_PLAYER_NAME);
			statusUpdateListener.playerDisconnected(playerName);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handleBettingRoundChangedUpdate(JSONObject parameters) {
		try {
			final int bettingRound = parameters.getInt(PARAM_BETTING_ROUND);
			statusUpdateListener.bettingRoundChanged(bettingRound);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}

	private void handleCardsReceivedUpdate(JSONObject parameters) {
		try {
			final JSONArray jsonCards = parameters.getJSONArray(PARAM_CARDS);
			String[] cards = new String[jsonCards.length()];
			for (int i = 0; i < jsonCards.length(); i++) {
				cards[i] = jsonCards.getString(i);
			}
			statusUpdateListener.receivedCards(cards);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}
	
	/**
	 * Creates a <code>ClientNetworkingChannel</code> and sets the server
	 * IP address and port number, as well as the {@link iRequestListener},
	 * the {@link iCardsRequestListener}, and the {@link iStatusUpdateListener}. 
	 * 
	 * @param ipAddress server IP Address (in the format "255.255.255.255"). 
	 * @param portNumber server port number.
	 * @param serverRequestListener <code>actionRequestListener</code> object for handling requests.
	 * @param cardsRequestListener <code>cardsRequestListener</code> object for handling requests.
	 * @param statusUpdateListener <code>statusUpdateListener</code> object for handling updates.
	 */
	public ClientNetworkingChannel(
			String ipAddress,
			int portNumber,
			iRequestListener serverRequestListener,
			iStatusUpdateListener statusUpdateListener
	) {
		this.ipAddress = ipAddress;
		this.portNumber = portNumber;
		this.requestListener = serverRequestListener;
		this.statusUpdateListener = statusUpdateListener;
	}
	
	private void handleActionRequest(JSONObject parameters) {
		try {
			final JSONArray jsonEncodedActions = parameters.getJSONArray(PARAM_POSSIBLE_ACTIONS);
			String[] possibleActions = new String[jsonEncodedActions.length()];
			for (int i = 0; i < jsonEncodedActions.length(); i++) {
				possibleActions[i] = jsonEncodedActions.getString(i);
			}
			final long betAmount = parameters.getLong(PARAM_BET_AMOUNT);
			long minBet = parameters.getLong(PARAM_MIN_BET);
			int timeout = parameters.getInt(PARAM_TIMEOUT);
			requestListener.actionRequested(
					possibleActions, betAmount, minBet, timeout);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}
	}
	
	private void handleCardsRequest(JSONObject parameters) {
		try {
			JSONArray jsonEncodedCards = parameters.getJSONArray(PARAM_CURRENT_CARDS);
			String[] currentCards = new String[jsonEncodedCards.length()];
			for (int i = 0; i < jsonEncodedCards.length(); i++) {
				currentCards[i] = jsonEncodedCards.getString(i);
			}
			int timeout = parameters.getInt(PARAM_TIMEOUT);
			requestListener.cardsRequested(currentCards, timeout);
		} catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_DECODING_REQUEST, e);
		}		
	}

	private synchronized void handleLeaveTableResponse(JSONObject parameters) {
		responseParameters = parameters;
		notifyAll();
	}

	private synchronized void handleListTablesResponse(JSONObject parameters) {
		responseParameters = parameters;
		notifyAll();
	}

	private synchronized void handleJoinTableResponse(JSONObject parameters) {
		responseParameters = parameters;
		notifyAll();
	}

	private synchronized void handleCreateTableResponse(JSONObject parameters) {
		responseParameters = parameters;
		notifyAll();
	}
	
	/**
	 * Sets the {@link iRequestListener} that will handle incoming action requests.
	 * 
	 * @param listener <code>actionRequestListener</code> object for handling requests.
	 */
	public void addRequestListener(iRequestListener listener) {
		requestListener = listener;
	}
	
	/**
	 * Sets the {@link iStatusUpdateListener} that will handle incoming status updates.
	 * 
	 * @param listener <code>statusUpdateListener</code> object for handling updates.
	 */
	public void addStatusUpdateListener(iStatusUpdateListener listener) {
		statusUpdateListener = listener;
	}
	
	/**
	 * Method for establishing connection with the server.
	 * <p>
	 * Ensure that the <code>actionRequestListener</code>, <code>cardsRequestListener</code>,
	 * and <code>statusUpdateListener</code> are set before calling this method or an
	 * IOException will be thrown.
	 * 
	 * @throws IOException if an I/O error occurs when creating the socket.
	 * @throws IllegalArgumentException if the port parameter is outside the specified 
	 * range of valid port values, which is between 0 and 65535, inclusive.
	 * @throws NullPointerException if address is null.
	 */
	public void connect() throws IOException {
		if (
				requestListener == null ||
				statusUpdateListener == null				
			) {
			throw new IOException("One or more of the listener objects not set.");
		}
		socket = new Socket(ipAddress, portNumber);
		outputBuffer = new PrintWriter(socket.getOutputStream());
		final IndependentBufferedReader in = new IndependentBufferedReader(
				new InputStreamReader(socket.getInputStream()));
		inputProcessingThread = new Thread(in);
		inputProcessingThread.setDaemon(true);
		inputProcessingThread.start();
	}
	
	/**
	 * Close connection with server.
	 * 
	 * @throws IOException if an I/O error occurs when closing the socket.
	 */
	public void disconnect() throws IOException {
		// Notify server
		String notificationMessage = NOTIFY_CONNECTION_CLOSE;
		sendMessage(notificationMessage);
		
		// Clean up and exit
		inputProcessingThread.interrupt();
		socket.close();
	}
	
	/**
	 * Creates a new table on the server and joins the table.
	 * @param tableName name of the table - used by other players to connect.
	 * @param myPlayerName name chosen by the player.
	 * @param maxPlayers the maximum number of players that are allowed to join (<=6).
	 * @param startingChips the amount of chips that each player starts with 
	 * (must be at least 10x big blind).
	 * @param smallBlind the amount of small blind to set for the table.
	 * @param bigBlind the amount of big blind to set for the table.
	 * @return object containing all the information about the game.
	 * @throws NameTakenException if the table name or the player name is taken.
	 * @throws IllegalCharactersException if the table name or player name 
	 * contains illegal characters.
	 * @throws IllegalParameterValueException if one or more of the parameters is out of bounds.
	 * @throws ServerCouldNotCompleteRequestException if the server could not complete request
	 * for another reason - e.g. client is already connected to a table.
	 * @throws RequestTimedOutException if the request timed out.
	 */
	public GameStatus createTable(String tableName, 
							String myPlayerName, 
							int maxPlayers, 
							long startingChips, 
							long smallBlind, 
							long bigBlind)
		throws NameTakenException, 
		IllegalCharactersException,
		IllegalParameterValueException,
		ServerCouldNotCompleteRequestException, 
		RequestTimedOutException
	{
		// Object to be returned
		GameStatus gameStatus = null;
		
		// Try to prepare JSON formatted request
		try {
			if (stringContainsIllegalCharacters(tableName)) {
				throw new IllegalCharactersException(ERROR_MSG_TABLE_NAME_ILLEGAL_CHARACTERS);
			}
			if (stringContainsIllegalCharacters(myPlayerName)) {
				throw new IllegalCharactersException(ERROR_MSG_PLAYER_NAME_ILLEGAL_CHARACTERS);
			}
			
			JSONObject jsonEncodedParameters = new JSONObject();
			jsonEncodedParameters.put(PARAM_MY_PLAYER_NAME, myPlayerName);
			jsonEncodedParameters.put(PARAM_TABLE_NAME, tableName);
			jsonEncodedParameters.put(PARAM_MAX_PLAYERS, maxPlayers);
			jsonEncodedParameters.put(PARAM_STARTING_CHIPS, startingChips);
			jsonEncodedParameters.put(PARAM_SMALL_BLIND, smallBlind);
			jsonEncodedParameters.put(PARAM_BIG_BLIND, bigBlind);
			String jsonString = jsonEncodedParameters.toString();
			String requestMessage = REQUEST_CREATE_TABLE + DELIMITER + jsonString;
			
			// Synchronize threads in order to exchange request and response
			synchronized (this) {
				// Flush previous response and send request
				responseParameters = null;
				sendMessage(requestMessage);
	
				// Wait for response 
				long timeToWait = SERVER_RESPONSE_TIMEOUT;
				long timeAtStart = System.currentTimeMillis();
				while (responseParameters == null) {
					try {
						wait(timeToWait);
						timeToWait -= System.currentTimeMillis() - timeAtStart;
						if (timeToWait <= 0) {
							throw new RequestTimedOutException("The server did not respond.");
						}
					}
					catch (InterruptedException e) {
						String message = "Interrupted while waiting for response from server.";
						throw new RequestTimedOutException(message);
					}
				}
				
				// Check response
				try {
					boolean returnResult = responseParameters.getBoolean(PARAM_RETURN_RESULT);
					// If table created successfully get game status data
					if (returnResult) {
						JSONObject jsonGameStatus = 
								responseParameters.getJSONObject(PARAM_GAME_STATUS);
						gameStatus = GameStatus.fromJSONObject(jsonGameStatus);
					}
					
					// If error occurred throw appropriate exception
					else {
						final String exceptionName = responseParameters.getString(PARAM_EXCEPTION);
						String message = responseParameters.getString(PARAM_EXCEPTION_MESSAGE);
						
						try {
							final Exception exception = recreateException(exceptionName, message);
							if (exception instanceof NameTakenException) {
								throw (NameTakenException) exception;
							}
							else if (exception instanceof IllegalCharactersException) {
								throw (IllegalCharactersException) exception;
							}
							else if (exception instanceof IllegalParameterValueException) {
								throw (IllegalParameterValueException) exception;
							}
							else if (exception instanceof ServerCouldNotCompleteRequestException) {
								throw (ServerCouldNotCompleteRequestException) exception;
							}
							else if (exception instanceof RequestTimedOutException) {
								throw (RequestTimedOutException) exception;
							}
							else {
								final String fallbackMessage = "Unknown response from server.";
								throw new ServerCouldNotCompleteRequestException(fallbackMessage);
							}
						}
						catch (InstantiationException e) {
							final String fallbackMessage = "Unknown response from server.";
							throw new ServerCouldNotCompleteRequestException(fallbackMessage);
						}
					}
				}
				
				// If error occurs while processing response, throw checked exception.
				catch (JSONException e) {
					String message = "Unable to process response from server.";
					throw new ServerCouldNotCompleteRequestException(message);
				}
			}
		}
		
		// If error occurs while preparing request, throw unchecked exception.
		catch (JSONException e) {
			throw new RuntimeException(e);
		}
		
		return gameStatus;
	}

	/**
	 * Returns a list of tables created on the server.
	 * 
	 * @return list of tables created on the server or <code>null</code> if there are no tables.
	 * @throws ServerCouldNotCompleteRequestException if the server could not complete request
	 * for some reason - e.g. client is already connected to a table.
	 * @throws RequestTimedOutException if the request timed out.
	 */
	public String[] getTableList() 
			throws ServerCouldNotCompleteRequestException, RequestTimedOutException 
	{
		// Prepare request
		String requestMessage = REQUEST_LIST_TABLES;
		
		// Synchronize threads in order to exchange request and response
		synchronized (this) {
			// Flush previous response and send request
			responseParameters = null;
			sendMessage(requestMessage);

			// Wait for response 
			long timeToWait = SERVER_RESPONSE_TIMEOUT;
			long timeAtStart = System.currentTimeMillis();
			while (responseParameters == null) {
				try {
					wait(timeToWait);
					timeToWait -= System.currentTimeMillis() - timeAtStart;
					if (timeToWait <= 0) {
						throw new RequestTimedOutException("The server did not respond.");
					}
				}
				catch (InterruptedException e) {
					String message = "Interrupted while waiting for response from server.";
					throw new RequestTimedOutException(message);
				}
			}
			
			// Check response
			try {
				boolean returnResult = responseParameters.getBoolean(PARAM_RETURN_RESULT);
				// If request completed successfully, decode and return table list.
				if (returnResult) {
					JSONArray jsonTableList = responseParameters.getJSONArray(PARAM_TABLE_LIST);
					String[] tableList = new String[jsonTableList.length()];
					for (int i = 0; i < jsonTableList.length(); i++) {
						tableList[i] = jsonTableList.getString(i);
					}
					return tableList;
				}
				// If request failed, throw appropriate error.
				else {
					final String exceptionName = responseParameters.getString(PARAM_EXCEPTION);
					final String message = responseParameters.getString(PARAM_EXCEPTION_MESSAGE);
					
					// Throw appropriate exception
					try {
						final Exception exception = recreateException(exceptionName, message);
						if (exception instanceof ServerCouldNotCompleteRequestException) {
							throw (ServerCouldNotCompleteRequestException) exception;
						}
						else if (exception instanceof RequestTimedOutException) {
							throw (RequestTimedOutException) exception;
						}
						else {
							final String fallbackMessage = "Unknown response from server.";
							throw new ServerCouldNotCompleteRequestException(fallbackMessage);
						}
					}
					catch (InstantiationException e) {
						final String fallbackMessage = "Unknown response from server.";
						throw new ServerCouldNotCompleteRequestException(fallbackMessage);
					}
				}
			}
			
			// If error occurs while processing response, throw checked exception.
			catch (JSONException e) {
				String message = "Unable to process response from server.";
				throw new ServerCouldNotCompleteRequestException(message);
			}
		}
	}
	
	/**
	 * Joins an existing table on the server.
	 * 
	 * @param tableName table name. Use {@link #getTableList()} to retrieve table names.
	 * @param myPlayerName name chosen by the player.
	 * @return object containing all the information about the game.
	 * @throws TableFullException if the table is full.
	 * @throws NameTakenException if the player name has been taken.
	 * @throws IllegalCharactersException if the player name contains illegal characters.
	 * @throws ServerCouldNotCompleteRequestException if the server could not complete request
	 * for another reason - e.g. client is already connected to a different table.
	 * @throws RequestTimedOutException if the request timed out.
	 * @throws NoSuchTableException if the specified table does not exist.
	 */
	public GameStatus joinTable(String tableName, String myPlayerName)
			throws TableFullException,
			NameTakenException,
			NoSuchTableException,
			IllegalCharactersException, 
			ServerCouldNotCompleteRequestException, 
			RequestTimedOutException
	{
		if (stringContainsIllegalCharacters(tableName)) {
			throw new IllegalCharactersException(ERROR_MSG_TABLE_NAME_ILLEGAL_CHARACTERS);
		}
		if (stringContainsIllegalCharacters(myPlayerName)) {
			throw new IllegalCharactersException(ERROR_MSG_PLAYER_NAME_ILLEGAL_CHARACTERS);
		}
		
		// Object to be returned
		GameStatus gameStatus = null;
		
		// Try to prepare JSON formatted request
		try {
			JSONObject jsonEncodedParameters = new JSONObject();
			jsonEncodedParameters.put(PARAM_MY_PLAYER_NAME, myPlayerName);
			jsonEncodedParameters.put(PARAM_TABLE_NAME, tableName);
			String jsonString = jsonEncodedParameters.toString();
			String requestMessage = REQUEST_JOIN_TABLE + DELIMITER + jsonString;
			
			// Synchronize threads in order to exchange request and response
			synchronized (this) {
				// Flush previous response and send request
				responseParameters = null;
				sendMessage(requestMessage);
	
				// Wait for response 
				long timeToWait = SERVER_RESPONSE_TIMEOUT;
				long timeAtStart = System.currentTimeMillis();
				while (responseParameters == null) {
					try {
						wait(timeToWait);
						timeToWait -= System.currentTimeMillis() - timeAtStart;
						if (timeToWait <= 0) {
							throw new RequestTimedOutException("The server did not respond.");
						}
					}
					catch (InterruptedException e) {
						String message = "Interrupted while waiting for response from server.";
						throw new RequestTimedOutException(message);
					}
				}
				
				// Check response
				try {
					boolean returnResult = responseParameters.getBoolean(PARAM_RETURN_RESULT);
					
					// If table joined successfully get game status data
					if (returnResult) {
						JSONObject jsonGameStatus = 
								responseParameters.getJSONObject(PARAM_GAME_STATUS);
						gameStatus = GameStatus.fromJSONObject(jsonGameStatus);
					}
					
					// If error occurred throw appropriate exception
					else {
						final String exceptionName = responseParameters.getString(PARAM_EXCEPTION);
						final String message = 
								responseParameters.getString(PARAM_EXCEPTION_MESSAGE);
						
						try {
							final Exception exception = recreateException(exceptionName, message);
							if (exception instanceof TableFullException) {
								throw (TableFullException) exception;
							}
							if (exception instanceof NameTakenException) {
								throw (NameTakenException) exception;
							}
							else if (exception instanceof NoSuchTableException) {
								throw (NoSuchTableException) exception;
							}
							else if (exception instanceof IllegalCharactersException) {
								throw (IllegalCharactersException) exception;
							}
							else if (exception instanceof ServerCouldNotCompleteRequestException) {
								throw (ServerCouldNotCompleteRequestException) exception;
							}
							else if (exception instanceof RequestTimedOutException) {
								throw (RequestTimedOutException) exception;
							}
							else {
								final String fallbackMessage = "Unknown response from server.";
								throw new ServerCouldNotCompleteRequestException(fallbackMessage);
							}
						}
						catch (InstantiationException e) {
							final String fallbackMessage = "Unknown response from server.";
							throw new ServerCouldNotCompleteRequestException(fallbackMessage);
						}
					}
				}
				
				// If error occurs while processing response, throw checked exception.
				catch (JSONException e) {
					String message = "Unable to process response from server.";
					throw new ServerCouldNotCompleteRequestException(message);
				}
			}
		}
		
		// If error occurs while preparing request, throw unchecked exception.
		catch (JSONException e) {
			throw new RuntimeException(e);
		}
		
		return gameStatus;
	}
	
	/**
	 * Leave currently joined table.
	 * 
	 * @throws ServerCouldNotCompleteRequestException if the server could not complete request
	 * for another reason - e.g. client is not connected to a table.
	 * @throws RequestTimedOutException if the request timed out.
	 */
	public void leaveTable() 
			throws ServerCouldNotCompleteRequestException, RequestTimedOutException 
	{
		// Prepare request
		String requestMessage = REQUEST_LEAVE_TABLE;
		
		// Synchronize threads in order to exchange request and response
		synchronized (this) {
			// Flush previous response and send request
			responseParameters = null;
			sendMessage(requestMessage);

			// Wait for response 
			long timeToWait = SERVER_RESPONSE_TIMEOUT;
			long timeAtStart = System.currentTimeMillis();
			while (responseParameters == null) {
				try {
					wait(timeToWait);
					timeToWait -= System.currentTimeMillis() - timeAtStart;
					if (timeToWait <= 0) {
						throw new RequestTimedOutException("The server did not respond.");
					}
				}
				catch (InterruptedException e) {
					String message = "Interrupted while waiting for response from server.";
					throw new RequestTimedOutException(message);
				}
			}
			
			// Check response
			try {
				boolean returnResult = responseParameters.getBoolean(PARAM_RETURN_RESULT);
				if (!returnResult) {
					final String exceptionName = responseParameters.getString(PARAM_EXCEPTION);
					final String message = responseParameters.getString(PARAM_EXCEPTION_MESSAGE);
					
					// Throw appropriate exception
					try {
						final Exception exception = recreateException(exceptionName, message);
						if (exception instanceof ServerCouldNotCompleteRequestException) {
							throw (ServerCouldNotCompleteRequestException) exception;
						}
						else if (exception instanceof RequestTimedOutException) {
							throw (RequestTimedOutException) exception;
						}
						else {
							final String fallbackMessage = "Unknown response from server.";
							throw new ServerCouldNotCompleteRequestException(fallbackMessage);
						}
					}
					catch (InstantiationException e) {
						final String fallbackMessage = "Unknown response from server.";
						throw new ServerCouldNotCompleteRequestException(fallbackMessage);
					}
				}
			}
			
			// If error occurs while processing response, throw checked exception.
			catch (JSONException e) {
				String message = "Unable to process response from server.";
				throw new ServerCouldNotCompleteRequestException(message);
			}
		}
	}

	/**
	 * Call this method to respond when the {@link 
	 * iRequestListener#actionRequested(String[], long, long, int) actionRequested}
	 * method of the {@link iRequestListener} gets invoked.
	 * <p>
	 * The possible actions a player can perform are:
	 * {@link iRequestListener#ACTION_FOLD ACTION_FOLD}, 
	 * {@link iRequestListener#ACTION_CHECK ACTION_CHECK},
	 * {@link iRequestListener#ACTION_BET ACTION_BET}, 
	 * {@link iRequestListener#ACTION_CALL ACTION_CALL},
	 * {@link iRequestListener#ACTION_RAISE ACTION_RAISE}, 
	 * or {@link iRequestListener#ACTION_ALLIN ACTION_ALLIN}.
	 * <p>
	 * The server will only accept as a response one the actions specified in the {@link 
	 * iRequestListener#actionRequested(String[], long, long, int) actionRequested}
	 * method of the {@link iRequestListener}.
	 * 
	 * @param action the action player wants to perform.
	 * @param amount depending on the action: amount bet, amount raised, amount all-in,
	 * or <code>0</code> otherwise. 
	 */
	public void respondToActionRequest(String action, long amount) {
		try {
			// Prepare request
			JSONObject jsonParameters = new JSONObject();
			jsonParameters.put(PARAM_ACTION, action);
			jsonParameters.put(PARAM_AMOUNT, amount);
			String jsonString = jsonParameters.toString();
			String requestMessage = RESPONSE_CHOSEN_ACTION + DELIMITER + jsonString;
			
			// Send request
			sendMessage(requestMessage);
		}

		// If error occurs while preparing request, throw unchecked exception.
		catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_ENCODING_REQUEST, e);
			throw new RuntimeException(e);
		}
	}
	
	/**
	 * Call this method to respond when the
	 * {@link iCardsRequestListener#cardsRequested(String[], int) cardsRequested} method of the
	 * {@link iCardsRequestListener} gets invoked.
	 * <p>
	 * The <code>cards</code> parameter is an array tha should contain only the cards
	 * a player wants to exchange.
	 * <p>
	 * Each card should be represented by a two-character String. The first
	 * character signifies the rank (2, 3, 4, 5, 6, 7, 8, 9, T, J, Q, K, A).
	 * And the second character signifies the suit (S, H, D, C).
	 * For example "QS" means a queen of spades.
	 * 
	 * @param chosenCards the cards a player would like exchanged.
	 */
	public void respondToCardsRequest(String[] chosenCards) {
		try {
			// Prepare request
			JSONObject jsonParameters = new JSONObject();
			jsonParameters.put(PARAM_CHOSEN_CARDS, chosenCards);
			String jsonString = jsonParameters.toString();
			String requestMessage = RESPONSE_CHOSEN_CARDS + DELIMITER + jsonString;
			
			// Send request
			sendMessage(requestMessage);
		}

		// If error occurs while preparing request, throw unchecked exception.
		catch (JSONException e) {
			LOGGER.log(Level.SEVERE, ERROR_MSG_ENCODING_REQUEST, e);
		}
	}
	
	private Exception recreateException(String exceptionName, String message) 
			throws InstantiationException
	{
		try {
			final Class<?> exceptionClass = Class.forName(exceptionName);
			final Constructor<?> constructor = 
					exceptionClass.getConstructor(new Class[] {String.class});
			final Exception exception = (Exception) constructor.newInstance(new Object[] {message});
			return exception;
		} 
		catch (ClassNotFoundException | 
				NoSuchMethodException | 
				SecurityException | 
				InstantiationException | 
				IllegalAccessException | 
				IllegalArgumentException | 
				InvocationTargetException e) 
		{
			// exceptionName represents an exception we can't handle
			throw new InstantiationException();
		}
	}
	
	private void sendMessage(String formattedResponse) {
		synchronized(outputBuffer) {
			outputBuffer.println(formattedResponse);
			outputBuffer.flush();
			if (DEBUGGING_ON) {
				String time = getTime();
				System.out.println(time + ": Client sent message: " + formattedResponse);
			}
		}
	}
	
	private String getTime() {
		final DateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
		final Calendar cal = Calendar.getInstance();
		String time = dateFormat.format(cal.getTime());
		return time;
	}
	
	private boolean stringContainsIllegalCharacters(String string) {
		if (string == null) {
			return true;
		}
		
		for (int i = 0; i < string.length(); i++) {
			final char c = string.charAt(i);
			if (c == ';' || c == '{' || c == '}' || c == '[' 
					|| c == ']' || c == '"' || c == ':' || c == ',') {
				return true;
			}
		}
		return false;
	}
}
